Maeiee Weekly No.27:如何编程绘制K线图
K线图是日常炒股中最常使用的图表。从软件编程角度,如何从头开发一个 K 线图组件呢?当然,作为开发者,通常不需要从头实现,因为业界不论哪个技术栈,都有成熟的开源库可供使用。但是,如果你和我一样,不满足调用现有的 API,想要从头绘制一个,本文中总结了一些关键原理。
本文站在了巨人的肩膀上,源于我在 Flutter 下找到了一个 K 线图图表库 k_chart。k_chart 绘制效果比较精美,同时依托于 Flutter 的界面渲染能力,也具备较好的体验。但是我发现它在扩展性上有些待完善,于是我打算 fork 一份进行扩展,顺便将底层的绘制原理研究一番。因此,本文也可认为是 k_chart 的源码研究,以及 Flutter 复杂自定义组件绘制的参考资料。
KChartWidget 组件
该库是 k_chart 库对外提供的 Widget,即 K 线图组件,开发者将它插入布局中即可。
属性
该组件中包含大量传入属性,其中一部分:
List<KLineEntity>? datas
:K 线图数据MainState mainState
:主图叠加指标,可选 MA 均线、BOOL 布林带SecondaryState secondaryState
:副图指标,可选 MACD、KDJ、RSI、WR、CCIbool isTapShowInfoDialog
:点击 K 线弹出一个详情数据气泡Function(bool)? onLoadMore
:左右边缘加载更多回调ChartColors chartColors
:图表样式ChartStyle chartStyle
:图表样式
其中:
KLineEntity
同时包含 K 线数据和指标数据,后文中将介绍。- KChartWidget 只支持一个副图
build 绘制
核心的 K 线部分是用 Flutter 的 CustomPaint(Canvas)绘制出来的,对应的 Painter 是 ChartPainter
布局结构为:
- GestureDetector:点击、拖拽、缩放、长安手势交互
- Stack
- CustomPaint:ChartPainter
- 详情数据气泡
- Stack
KLineEntity 行情数据
KLineEntity 表示一天内的股票数据,因此 List<KLineEntity>? datas
表示一段时间内的行情数据。
KLineEntity 的声明如下:
class KEntity
with
CandleEntity,
VolumeEntity,
KDJEntity,
RSIEntity,
WREntity,
CCIEntity,
MACDEntity {}
这里使用了 Dart 的 mixin 特性,可以简单理解为将多个类的数据拼装到一起,成为 KEntity。 比如,CandleEntity 为蜡烛图数据,声明如下:
mixin CandleEntity {
late double open;
late double high;
late double low;
late double close;
List<double>? maValueList;
//上轨线
double? up;
//中轨线
double? mb;
//下轨线
double? dn;
double? BOLLMA;
}
VolumeEntity 中包含成交量数据:
mixin VolumeEntity {
late double open;
late double close;
late double vol;
double? MA5Volume;
double? MA10Volume;
}
需要有开发者创建 KLineEntity 来拼装数据,在数据拼装时,开发者需要将行情数据、指标数据都传入 KLineEntity 中。
BaseChartPainter 绘制基类
该类是绘图基类,负责图表绘制的通用工作。主图 K 线和副图指标都基于 BaseChartPainter 的框架能力进行扩展,因此线分析这个类。
initRect 功能分区
根据 Canvas 的 Size 将画布分为 3 块(Rect):
- K 线区:mainHeight
- 成交量区:mVolRect
- 副图区:mSecondaryRect
对应的的高度分别为:mainHeight、volHidden、secondaryHeight
calculateValue 数据截取
KChartWidget 中股票数据是允许左右横滑的,可是并没有一个类似 ScrollView 的组件负责滚动。用户看到的滚动效果,实际上是 Canvas 中实时更新绘制的。
该组件会根据用户滑动手势的增量,实时计算出,股票数据应当出现在屏幕上的起止 index。对应的计算方法为 calculateValue。
核心是算出两个 index:
- mStartIndex:出现在屏幕上的最少 index
- mStopIndex:出现在屏幕上的最大 index
之后遍历数据,取出 KLineEntity 用于计算。
抽象方法
BaseChartPainter 定义了一系列抽象方法,供子类实现,具体包括:
- initChartRenderer:初始化
- drawBg:绘制背景
- drawGrid:绘制网格
- drawChart:绘制图表
- drawVerticalText:绘制右边值
- drawDate:绘制日期
- drawText:绘制文本
- drawMaxAndMin:绘制最大最小值
- drawNowPrice:绘制当前价格
- drawCrossLine:绘制交叉线
- drawCrossLineText:绘制交叉线的值
ChartPainter 主图绘制
该类是整个库中最核心的类,负责如何在空白画布(Canvas)上使用基本绘图能力(Painter),将 K 线图给画出来。
继承 BaseChartPainter
ChartPainter 采用了一种代理模式,首先 ChartPainter 自身继承自 BaseChartPainter,同时 ChartPainter 中还有三个代理类,分别负责主图、成交量图和副图的绘制,分别是 MainRenderer、VolRenderer、SecondaryRenderer。它们均继承自 BaseChartPainter。
ChartPainter drawChart
ChartPainter 的图表绘制方法,在该方法中,会遍历股票数据,并分发给各个代理负责绘制:
for (int i = mStartIndex; datas != null && i <= mStopIndex; i++) {
// 当前数据
KLineEntity? curPoint = datas?[i];
// 前一交易日数据
if (curPoint == null) continue;
KLineEntity lastPoint = i == 0 ? curPoint : datas![i - 1];
// 当前横坐标
double curX = getX(i);
// 前一工作日横坐标
double lastX = i == 0 ? curX : getX(i - 1);
// 主图代理绘制
mMainRenderer.drawChart(lastPoint, curPoint, lastX, curX, size, canvas);
// 成交量代理绘制
mVolRenderer?.drawChart(lastPoint, curPoint, lastX, curX, size, canvas);
// 副图代理绘制
mSecondaryRenderer?.drawChart(
lastPoint, curPoint, lastX, curX, size, canvas);
}
主图 MainRenderer
负责主图绘制。
drawChart 绘制主图曲线
drawChart 方法最为重要,蜡烛图、曲线、主图指标都是在这里完成的。
具体代码如下:
@override
void drawChart(
CandleEntity lastPoint, // 前一交易日数据
CandleEntity curPoint, // 当前交易日数据
double lastX, // 前一交易日横坐标
double curX, // 当前交易日横坐标
Size size, Canvas canvas) { // 图表尺寸与画布
// 主图支持两种绘制方式,画线,或者画 K线
if (isLine) { // 画线模式
drawPolyline(lastPoint.close, curPoint.close, canvas, lastX, curX);
} else { // 画K线模式
drawCandle(curPoint, canvas, curX);
// 画主图上叠加指标
if (state == MainState.MA) {
drawMaLine(lastPoint, curPoint, canvas, lastX, curX);
} else if (state == MainState.BOLL) {
drawBollLine(lastPoint, curPoint, canvas, lastX, curX);
}
}
}
drawCandle 画 K 线
该方法负责绘制一个交易日的 K 线蜡烛。
具体来说,通过 high、low、open、close,来绘制箱体和影线。
并且根据当日涨跌,填充不同的颜色。
drawMaLine 绘制均线
Entity 中有一个数组 List<double>? maValueList
,意思是可以同时绘制多条均线(最多3条)。
不过图标库自己不负责均线绘制,需要由开发者计算好后进行传入。
SecondaryRenderer
负责副图绘制。
drawChart 绘制副图曲线
通过 SecondaryState 枚举定义了支持的类型,根据不同类型画不同的线:
@override
void drawChart(MACDEntity lastPoint, MACDEntity curPoint, double lastX,
double curX, Size size, Canvas canvas) {
switch (state) {
case SecondaryState.MACD:
drawMACD(curPoint, canvas, curX, lastPoint, lastX);
break;
case SecondaryState.KDJ:
drawLine(lastPoint.k, curPoint.k, canvas, lastX, curX,
this.chartColors.kColor);
drawLine(lastPoint.d, curPoint.d, canvas, lastX, curX,
this.chartColors.dColor);
drawLine(lastPoint.j, curPoint.j, canvas, lastX, curX,
this.chartColors.jColor);
break;
case SecondaryState.RSI:
drawLine(lastPoint.rsi, curPoint.rsi, canvas, lastX, curX,
this.chartColors.rsiColor);
break;
case SecondaryState.WR:
drawLine(lastPoint.r, curPoint.r, canvas, lastX, curX,
this.chartColors.rsiColor);
break;
case SecondaryState.CCI:
drawLine(lastPoint.cci, curPoint.cci, canvas, lastX, curX,
this.chartColors.rsiColor);
break;
default:
break;
}
}
drawMACD MACD 指标绘制
以 MACD 为例,柱子和线的画法:
void drawMACD(MACDEntity curPoint, Canvas canvas, double curX,
MACDEntity lastPoint, double lastX) {
final macd = curPoint.macd ?? 0;
double macdY = getY(macd);
double r = mMACDWidth / 2;
double zeroy = getY(0);
if (macd > 0) {
canvas.drawRect(Rect.fromLTRB(curX - r, macdY, curX + r, zeroy),
chartPaint..color = this.chartColors.upColor);
} else {
canvas.drawRect(Rect.fromLTRB(curX - r, zeroy, curX + r, macdY),
chartPaint..color = this.chartColors.dnColor);
}
if (lastPoint.dif != 0) {
drawLine(lastPoint.dif, curPoint.dif, canvas, lastX, curX,
this.chartColors.difColor);
}
if (lastPoint.dea != 0) {
drawLine(lastPoint.dea, curPoint.dea, canvas, lastX, curX,
this.chartColors.deaColor);
}
}
手势缩放
k_chart 中大量采用自定义绘制,能实现更加强大的绘制效果,比如手势缩放。
手势缩放功能,指的是 K 线能够像手机浏览图片一样双指放大与缩小,平且效果十分平滑。
值得一提的是,依托于 Flutter 的跨端特性,该缩放不仅在手机上生效,在电脑上也同样生效,前提的你的电脑要支持屏幕触摸。
具体实现来到 KChartWidget,其布局中最外层是一个 GestureDetector 手势监听器,监听了一系列手势,其中包含放大、缩小:
onScaleUpdate: (details) {
if (isDrag || isLongPress) return;
mScaleX = (_lastScale * details.scale).clamp(0.5, 2.2);
notifyChanged();
},
在该方法中,计算出一个新的横向缩放因子 mScaleX,通过 notifyChanged 刷新组件。notifyChanged 内部是一个 setState((){})
,这是flutter组件的刷新方法,该方法调用后,KChartWidget 内部组件都会进行刷新,这就包括了前面的各个 Renderer。
这样,便实现了 K 线图的放大缩小手势功能。
小结
至此,这个库的核心原理已经研究清楚了。我的想法总结如下。
扩展性
基于代码来扩展,还是比较容易地,只要沿着作者地逻辑,不断对枚举进行扩充即可。包括主图、副图扩展。
Entity 也是具备扩展性的,对于新指标,也通过 mixin 进行扩充即可。
这个库如果以开源库粒度提供,扩展性会差一些。但如果以源码粒度提供,还是比较好扩展的。
理想架构
我也在脑中思考了一下,按照我的审美的理想架构:
比如会考虑如下的布局:
- HorizontalScrollView
- Column
- Stack(主图)
- KChart
- MaLIne
- ……
- Stack(成交量图)
- Stack(副图1)
- Stack(副图2)
- Stack(主图)
- Column
其中,区别在于:
- 尽可能少用 CustomPainter,尽可能多用 Flutter 组件库
- 将不同图片用不同 CustomPainter 分别实现,放在 Flutter 布局中组装,这样更加灵活
- 支持多副图结构
减少 CustomPainter 的使用,或者说是将 CustomPainter 的绘制单元拆分为更小粒度,封装成一系列专门组件,这样的好处是可以更加声明式使用,这样可以实现开源库粒度的灵活性。